礙於篇幅緣故,過多細節的部分,會挑重點講述,如有疑問歡迎留言討論
昨天我們簡介如何使用 flask 實作 backend API server。
此 API server 所提供的服務,
皆是 搜尋引擎 與 資料庫 相關,
背後連接的也是我們在前面章節所提到過的 meilisearch 與 supabase。
今天我們針對與 meilisearch 連接的部分重點說明一下。
如何透過 python package 連接 meilisearh 的細節,
可以參考 meilisearch-python 與 meilisearch API reference。
首先,我們在 APIServer __init__ 中,需要宣告 meilisearch client,
並且,我們接下來會以 GET /docs/v1/search 舉例。
import meilisearch as ms
class APIServer():
    def __init__(self, name, host="0.0.0.0", port=5000, debug=False):
        # init meilisearch client
        url = os.getenv("MEILISEARCH_URL")
        docs_index = os.getenv("MEILISEARCH_DOCUMENTS_INDEX")
        keywords_index = os.getenv("MEILISEARCH_KEYWORDS_INDEX")
        self.ms_url = url
        self.ms_docs_index = docs_index
        self.ms_keywords_index = keywords_index
        self.ms_client = ms.Client(self.ms_url)
        
        # init API
        self.add_endpoint(
            endpoint="/docs/v1/search",
            endpoint_name="search_docs",
            handler=self.search_docs,
            handler_params={"index": self.ms_docs_index},
            req_methods=["GET"]
        )
我們先看看 API 的 request 和 response。
我們若用 curl 測試,
curl -XGET "http://0.0.0.0:5000/docs/v1/search?q=api&page=0&limit=10"
得到的 response 格式為
{
    "query":"api",
    "total":850,
    "result":[
        {
            "position":0,
            "title":"\n                                                API 身分驗證\n                    ",
            "link":"https://ithelp.ithome.com.tw/articles/10223365",
            "snippet":"只有該 token 能呼叫 <mark>API</mark>\n\n其中鎖 IP 是最為麻煩的方法,因為 IP 為網路層(Network Layer)即可得知該內容,但若不同的路徑要有不同的限制時,那就只能在應用層(Application Layer)處理,這可能會令開發者不清楚在哪處理比較恰當。另一個問題則是,只要一鎖 IP,代表未來系統架構的彈性就可能會降低。\n而 token 則是相較彈性,且有相關的規範和安全注意事項可以參考。\n另外一開始有提到,使用者直接呼叫 <mark>API</mark> 也是類似這個場景",
            "lastmod":1569915457,
            "about_this_result":{
                "author":{
                    "name":"Miles",
                    "link":"https://ithelp.ithome.com.tw/users/20102562/ironman"
                },
                "series":{
                    "name":"我是誰?我在哪?",
                    "link":"https://ithelp.ithome.com.tw/users/20102562/ironman/2923"
                },
                "hashtags":[
                    "11th鐵人賽"
                ],
                "keywords":[
                    "使用者",
                    "密碼",
                    "驗證",
                    "身分",
                    "利用"
                ],
                "reading_time":5
            }
        }
    ]
}
在 search handler function 中,我們先 parse request arguments。
其中,我們先 parse 並檢查 request arguments 中,
是否包含所有必要的 arguments: "q", "page", and "limit"。
def search_docs(self, params: dict):
    # parse and check request
    req_args_key_set = set(request.args.keys())
    req_must_key_set = {'q', 'page', 'limit'}
    if (req_must_key_set - req_args_key_set) != set():
        self.logger.error(
            "Missing params %s in doc search request.",
            ", ".join(req_must_key_set - req_args_key_set)
        )
        return Response(status=400, headers={})
    query = request.args.get('q', type=str)
    page = request.args.get('page', type=int)
    limit = request.args.get('limit', type=int)
    hashtags = request.args.getlist('hashtags', type=str)
接著,我們要準備 meilisearch sdk search request。
ms_request = {
    'offset': page * limit,
    'limit':  limit,
    'attributesToRetrieve': [
        'title',
        'href',
        'published_at_unix',
        'author_name',
        'author_href',
        'series_name',
        'series_href',
        'hashtags',
        'keywords',
        'reading_time'
    ],
    'attributesToHighlight': ['raw_hl_content'],
    'highlightPreTag': '<mark>',
    'highlightPostTag': '</mark>',
    'attributesToCrop': ['raw_hl_content:100'],
    'cropMarker': ''
}
raw_filter = []
if len(hashtags) > 0:
    for h_tag in hashtags:
        raw_filter.append(f"hashtags = {h_tag}")
ms_request['filter'] = ' AND '.join(raw_filter)
其中,attributesToRetrieve 代表回傳結果文章中要哪些 fields。
而其它與 highlight 和 snippets 相關的參數,在 Day 18 中也有介紹。
我們可以直接使用 meilisearch client 執行 search。
ms_index = params.get('index', None)
if not ms_index:
    return Response(status=500, headers={})
raw_resp = self.ms_client.index(ms_index).search(
    query,
    opt_params=ms_request
)
if not raw_resp:
    self.logger.error("Failed to run search request on Meilisearch.")
    return Response(status=500, headers={})
在從 meilisearch client 獲得 search response 後,
我們需要從回傳的資訊中,擷取我們所需,
並 format 好 API response 的格式。
resp = {
    'query': raw_resp['query'],
    'total': raw_resp['estimatedTotalHits'],
    'result': []
}
for i, raw_hit in enumerate(raw_resp['hits']):
    hit = {
        'position': i,
        'title':    raw_hit['title'],
        'link':     raw_hit['href'],
        'snippet':  raw_hit['_formatted']['raw_hl_content'],
        'lastmod':  raw_hit['published_at_unix'],
        'about_this_result': {
            'author': {
                'name': raw_hit['author_name'],
                'link': raw_hit['author_href']
            },
            'series': {
                'name': raw_hit['series_name'],
                'link': raw_hit['series_href']
            },
            "hashtags":     raw_hit['hashtags'],
            "keywords":     raw_hit['keywords'],
            "reading_time": raw_hit['reading_time']
        }
    }
    resp['result'].append(hit)
return Response(
    response=json.dumps(resp),
    status=200,
    headers={"Content-Type": "application/json"}
)
今天的內容好像有點長,
我們就把 supabase 的內容放到明天吧。